Skip to content

app: serve remote views on GET /remote - #730

Merged
foxnne merged 5 commits into
mainfrom
app/remote-view-host
Oct 22, 2026
Merged

foxnne merged 5 commits into
mainfrom
app/remote-view-host

Conversation

@foxnne

@foxnne foxnne commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Part of fizzyedit/work#40 (EMBEDDED_EDITOR_PLAN step 3, fizzyedit/work#32)

What changes

A remote client (an adapter in Blender, Unreal, …) can now connect to the control socket, GET /remote with Upgrade: fizzy-remote-view/1, and speak the sdk/remote protocol: open a view, send pointer/key/text input, ask for frames and get them. Without the upgrade header the answer is 426, with another method 405. The socket stays 0600 and same-user.

  • app/control/Link.zig + Server.zig: an upgraded connection becomes two byte queues. A reader and a writer task per connection do the waiting; the UI thread drains and fills the queues once per frame (Service.pump). No thread per view, and the UI thread never blocks.
  • app/remote/RemoteViewHost.zig: a ViewHost that the OS never shows. ViewHost's vtable is unchanged. Input waits in the host as dvui events until inject(win); keys and text only while the client says the view is focused. A view the client closes, or one that goes with a dropped connection, is dropped from its session.
  • app/remote/Session.zig / Service.zig: one conversation per client over Endpoint, and the registry. fizzy.remote.views (internal) lists the open views.
  • app/remote/encoder.zig: the stub, one quad filling the view.
  • app/remote/keys.zig: Key.code is read as a USB HID usage (the numbering SDL scancodes use). This is a decision I made, not one the plan states; please check it.
  • docs/CONTROL.md: the route and fizzy.remote.views.

SDK impact

  • None

(sdk_version untouched; sdk/remote is only imported by the app module, in build/sdk.zig.)

Verified

Tests in app/remote/tests.zig (fizzy-app-tests), among them a client on the real socket: create a view, send motion/click/key/text, ask a frame, drop the connection with a frame request in flight (the session and its view go, fizzy keeps running), then a second client served as before. One editor-facing body of ViewHost calls runs against a local fake host and the remote one. Input is injected into a dvui.testing window and checked at the view's offset. Server.zig has its own tests for the upgraded byte stream.

  • macOS:
  • Windows:
  • Linux: scripts/check-fmt.sh, build, build test, build test-integration (92/92 steps), build check-web, build test-sdk-version, build lint, build check-tests-reached
  • Web: check-web builds; the control server is native only

Follow-ups

  • The editor's frame loop does not draw remote views yet (TODO(seam) in Service.zig, EMBEDDED_EDITOR_PLAN step 4). Until it does, input waits in the host (up to 1024 events) and pump answers frame requests itself with the stub. I did not wire the Editor, so remote input cannot land on the main window at the wrong origin.
  • The real draw-list encoder replaces encoder.solidFill (TODO(seam)).
  • Touch, IME and clipboard messages are accepted and ignored for now.
  • The diff is a little over 800 lines outside tests and docs, counting Link/Server upgrade code that has to travel with the route.
  • I had no jj in this cloud session (its release download is blocked and the checkout has no .jj), so this branch was committed with git.

Generated by Claude Code

Limits and refusals

  • A connection past Service.max_sessions (8) is refused with plain HTTP 503 and a JSON body, never a 101; an adapter must treat any non-101 as refused. sdk/remote/Client.zig already maps a 503 to error.Closed and any other non-101 to FizzyTooOld, so no new error was added.
  • A dead session is closed, and cut off (Link.reapStuck) 2 s later if its peer will not read the bye, so a stuck peer cannot hold a slot or hang quit.
  • Known, accepted for now: no handshake/idle timeout, so 8 silent upgraded links hold all slots (the socket is the user's own, 0600). The seam is Session.pump, which could mark a session with no hello after a deadline dead.
  • Worst-case memory per connection is about three Link.max_backlogs (inbox, outbox, and the batch the writer took), stated in Service.zig.

@foxnne
foxnne force-pushed the app/remote-view-host branch from 95e3fdb to 813ee74 Compare October 21, 2026 19:09
claude and others added 3 commits October 21, 2026 17:12
A client upgrades a connection on the control socket (Upgrade:
fizzy-remote-view/1) and speaks the sdk/remote protocol: it opens views,
sends pointer, key and text input, and asks for frames. Each view is a
RemoteViewHost, a ViewHost the OS never shows; its frame is a stub, one
quad filling the view.

The server hands an upgraded connection to the app as two byte queues
(control/Link), so the UI thread reads and writes once a frame and no
thread is needed per view. A view the client closes, or that goes with a
dropped connection, closes its window. fizzy.remote.views lists them.

Part of fizzyedit/work#40

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mgAaDN3ndpHLtmoA72ZLQ
Fixes from reviewing the diff: a double free of the link when accepting
it ran out of memory; a close that could be missed by the writer task;
the link read after the app could free it; a slow peer that could not be
cut off (the link now shuts its socket down on overflow, and the stop
path shuts connections down before waiting on them); an unbounded
inbox. Input past the queue's cap now drops motion, never a press, a
release or a key-up, and a zero-sized resize is ignored.

Part of fizzyedit/work#40

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mgAaDN3ndpHLtmoA72ZLQ
The control link's backlog (8 MiB) sat below the protocol's 32 MiB frame cap, so a legal large frame dropped the connection; it is now 64 MiB, asserted at comptime against the cap. A view's queued text is capped at 1 MiB, a connection holds at most 64 views, and the service at 8 connections (a ninth gets 503). A duplicate view id, which the protocol layer already ends the stream for, is guarded in the session without a view_closed that would make the client forget the open view.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@foxnne
foxnne force-pushed the app/remote-view-host branch from c190b17 to 8e972c9 Compare October 21, 2026 22:16
A session that dies is closed, and if its peer never reads the bye the writer stays blocked: Link.reapStuck turns that close into an abort after 2 s, from the session's pump and from closeAll (which no longer spins forever). Tests: 503 for the ninth client on the socket, and a stuck writer whose slot frees.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@foxnne
foxnne force-pushed the app/remote-view-host branch from 8e972c9 to 4fbe091 Compare October 21, 2026 22:17
routes pumps ask for a frame while any remote link is closing, so the 2 s grace ends in an abort even when the app is idle. closeAll keeps waiting when its sleep is cancelled instead of freeing a session whose tasks may still run. The 503 test hands its status over with an atomic flag.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@foxnne
foxnne marked this pull request as ready for review October 21, 2026 23:52
@foxnne
foxnne added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit b0df757 Oct 22, 2026
11 checks passed
@foxnne
foxnne deleted the app/remote-view-host branch October 22, 2026 00:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants